import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import ts from "typescript";
import {
  addJSDocComment,
  BOOLEAN,
  NEVER,
  NULL,
  NUMBER,
  oapiRef,
  QUESTION_TOKEN,
  STRING,
  tsArrayLiteralExpression,
  tsEnum,
  tsIntersection,
  tsIsPrimitive,
  tsLiteral,
  tsModifiers,
  tsNullable,
  tsOmit,
  tsPropertyIndex,
  tsRecord,
  tsUnion,
  tsWithRequired,
  UNDEFINED,
  UNKNOWN,
} from "../lib/ts.js";
import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js";
import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";

/**
 * Transform SchemaObject nodes (4.8.24)
 * @see https://spec.openapis.org/oas/v3.1.0#schema-object
 */
export default function transformSchemaObject(
  schemaObject: SchemaObject | ReferenceObject,
  options: TransformNodeOptions,
  fromAdditionalProperties = false,
): ts.TypeNode {
  const type = transformSchemaObjectWithComposition(schemaObject, options, fromAdditionalProperties);
  if (typeof options.ctx.postTransform === "function") {
    const postTransformResult = options.ctx.postTransform(type, options);
    if (postTransformResult) {
      return postTransformResult;
    }
  }
  return type;
}

/**
 * Transform SchemaObjects
 */
export function transformSchemaObjectWithComposition(
  schemaObject: SchemaObject | ReferenceObject,
  options: TransformNodeOptions,
  fromAdditionalProperties = false,
): ts.TypeNode {
  /**
   * Unexpected types & edge cases
   */

  // missing/falsy type returns `never`
  if (!schemaObject) {
    return NEVER;
  }
  // `true` returns `unknown` (this exists, but is untyped)
  if ((schemaObject as unknown) === true) {
    return UNKNOWN;
  }
  // for any other unexpected type, throw error
  if (Array.isArray(schemaObject) || typeof schemaObject !== "object") {
    throw new Error(
      `Expected SchemaObject, received ${Array.isArray(schemaObject) ? "Array" : typeof schemaObject} at ${options.path}`,
    );
  }

  /**
   * ReferenceObject
   */
  if ("$ref" in schemaObject) {
    return oapiRef(schemaObject.$ref);
  }

  /**
   * const (valid for any type)
   */
  if (schemaObject.const !== null && schemaObject.const !== undefined) {
    return tsLiteral(schemaObject.const);
  }

  /**
   * enum (non-objects)
   * note: enum is valid for any type, but for objects, handle in oneOf below
   */
  if (
    Array.isArray(schemaObject.enum) &&
    (!("type" in schemaObject) || schemaObject.type !== "object") &&
    !("properties" in schemaObject) &&
    !("additionalProperties" in schemaObject)
  ) {
    // hoist enum to top level if string/number enum and option is enabled
    if (
      options.ctx.enum &&
      schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null)
    ) {
      let enumName = parseRef(options.path ?? "").pointer.join("/");
      // allow #/components/schemas to have simpler names
      enumName = enumName.replace("components/schemas", "");
      const metadata = schemaObject.enum.map((_, i) => ({
        name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i],
        description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i],
      }));

      // enums can contain null values, but dont want to output them
      let hasNull = false;
      const validSchemaEnums = schemaObject.enum.filter((enumValue) => {
        if (enumValue === null) {
          hasNull = true;
          return false;
        }

        return true;
      });
      const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, {
        shouldCache: options.ctx.dedupeEnums,
        export: true,
        // readonly: TS enum do not support the readonly modifier
      });
      if (!options.ctx.injectFooter.includes(enumType)) {
        options.ctx.injectFooter.push(enumType);
      }
      const ref = ts.factory.createTypeReferenceNode(enumType.name);
      return hasNull ? tsUnion([ref, NULL]) : ref;
    }
    const enumType = schemaObject.enum.map(tsLiteral);
    if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) {
      enumType.push(NULL);
    }

    const unionType = tsUnion(enumType);

    // hoist array with valid enum values to top level if string/number enum and option is enabled
    if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
      const parsed = parseRef(options.path ?? "");
      let enumValuesVariableName = parsed.pointer.join("/");
      // allow #/components/schemas to have simpler names
      enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
      enumValuesVariableName = `${enumValuesVariableName}Values`;

      // build a ref path for the type that ignores union indices (anyOf/oneOf) so
      // type references remain stable even when names include union positions
      const cleanedPointer: string[] = [];
      for (let i = 0; i < parsed.pointer.length; i++) {
        // Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
        const segment = parsed.pointer[i];
        if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) {
          const next = parsed.pointer[i + 1];
          if (/^\d+$/.test(next)) {
            // If we encounter something like "anyOf/0", we want to skip that part of the path
            i++;
            continue;
          }
        }
        cleanedPointer.push(segment);
      }
      const cleanedRefPath = createRef(cleanedPointer);

      const enumValuesArray = tsArrayLiteralExpression(
        enumValuesVariableName,
        // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
        fromAdditionalProperties
          ? ts.factory.createIndexedAccessTypeNode(
              oapiRef(cleanedRefPath, undefined, true),
              ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
            )
          : oapiRef(cleanedRefPath, undefined, true),
        schemaObject.enum as (string | number)[],
        {
          export: true,
          readonly: true,
          injectFooter: options.ctx.injectFooter,
        },
      );

      options.ctx.injectFooter.push(enumValuesArray);
    }

    return unionType;
  }

  /**
   * Object + composition (anyOf/allOf/oneOf) types
   */

  /** Collect oneOf/anyOf */
  function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[], unionKey: "anyOf" | "oneOf") {
    const output: ts.TypeNode[] = [];
    for (const [index, item] of items.entries()) {
      output.push(
        transformSchemaObject(item, {
          ...options,
          // include index in path so generated names from nested enums/enumValues are unique
          path: createRef([options.path, unionKey, String(index)]),
        }),
      );
    }

    return output;
  }

  /** Collect allOf with Omit<> for discriminators */
  function collectAllOfCompositions(items: (SchemaObject | ReferenceObject)[], required?: string[]): ts.TypeNode[] {
    const output: ts.TypeNode[] = [];
    for (const item of items) {
      let itemType: ts.TypeNode;
      // if this is a $ref, use WithRequired<X, Y> if parent specifies required properties
      // (but only for valid keys)
      if ("$ref" in item) {
        itemType = transformSchemaObject(item, options);

        const resolved = options.ctx.resolve<SchemaObject>(item.$ref);

        // make keys required, if necessary
        if (
          resolved &&
          typeof resolved === "object" &&
          "properties" in resolved &&
          // we have already handled this item (discriminator property was already added as required)
          !options.ctx.discriminators.refsHandled.includes(item.$ref)
        ) {
          // add WithRequired<X, Y> if necessary
          const validRequired = (required ?? []).filter((key) => !!resolved.properties?.[key]);
          if (validRequired.length) {
            itemType = tsWithRequired(itemType, validRequired, options.ctx.injectFooter);
          }
        }
      }
      // otherwise, if this is a schema object, combine parent `required[]` with its own, if any
      else {
        const itemRequired = [...(required ?? [])];
        if (typeof item === "object" && Array.isArray(item.required)) {
          itemRequired.push(...item.required);
        }
        itemType = transformSchemaObject({ ...item, required: itemRequired }, options);
      }

      const discriminator =
        ("$ref" in item && options.ctx.discriminators.objects[item.$ref]) || (item as any).discriminator;
      if (discriminator) {
        output.push(tsOmit(itemType, [discriminator.propertyName]));
      } else {
        output.push(itemType);
      }
    }
    return output;
  }

  // compile final type
  let finalType: ts.TypeNode | undefined = undefined;

  // core + allOf: intersect
  const coreObjectType = transformSchemaObjectCore(schemaObject, options);
  const allOfType = collectAllOfCompositions(schemaObject.allOf ?? [], schemaObject.required);
  if (coreObjectType || allOfType.length) {
    const allOf: ts.TypeNode | undefined = allOfType.length ? tsIntersection(allOfType) : undefined;
    finalType = tsIntersection([...(coreObjectType ? [coreObjectType] : []), ...(allOf ? [allOf] : [])]);
  }
  // anyOf: union
  // (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf)
  const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? [], "anyOf");
  if (anyOfType.length) {
    finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]);
  }
  // oneOf: union (within intersection with other types, if any)
  const oneOfType = collectUnionCompositions(
    schemaObject.oneOf ||
      ("type" in schemaObject &&
        schemaObject.type === "object" &&
        (schemaObject.enum as (SchemaObject | ReferenceObject)[])) ||
      [],
    "oneOf",
  );
  if (oneOfType.length) {
    // note: oneOf is the only type that may include primitives
    if (oneOfType.every(tsIsPrimitive)) {
      finalType = tsUnion([...(finalType ? [finalType] : []), ...oneOfType]);
    } else {
      finalType = tsIntersection([...(finalType ? [finalType] : []), tsUnion(oneOfType)]);
    }
  }

  // When no final type can be generated, fall back to unknown type (or related variants)
  if (!finalType) {
    if ("type" in schemaObject) {
      finalType = tsRecord(STRING, options.ctx.emptyObjectsUnknown ? UNKNOWN : NEVER);
    } else {
      finalType = UNKNOWN;
    }
  }

  if (finalType !== UNKNOWN && schemaObject.nullable) {
    finalType = tsNullable([finalType]);
  }

  return finalType;
}

/**
 * Handle SchemaObject minus composition (anyOf/allOf/oneOf)
 */
function transformSchemaObjectCore(schemaObject: SchemaObject, options: TransformNodeOptions): ts.TypeNode | undefined {
  if ("type" in schemaObject && schemaObject.type) {
    if (typeof options.ctx.transform === "function") {
      const result = options.ctx.transform(schemaObject, options);
      if (result && typeof result === "object") {
        if ("schema" in result) {
          if (result.questionToken) {
            return ts.factory.createUnionTypeNode([result.schema, UNDEFINED]);
          } else {
            return result.schema;
          }
        } else {
          return result;
        }
      }
    }

    // primitives
    // type: null
    if (schemaObject.type === "null") {
      return NULL;
    }
    // type: string
    if (schemaObject.type === "string") {
      return STRING;
    }
    // type: number / type: integer
    if (schemaObject.type === "number" || schemaObject.type === "integer") {
      return NUMBER;
    }
    // type: boolean
    if (schemaObject.type === "boolean") {
      return BOOLEAN;
    }

    // type: array (with support for tuples)
    if (schemaObject.type === "array") {
      // default to `unknown[]`
      let itemType: ts.TypeNode = UNKNOWN;
      // tuple type
      if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) {
        const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]);
        itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options)));
      }
      // standard array type
      else if (schemaObject.items) {
        if (hasKey(schemaObject.items, "type") && schemaObject.items.type === "array") {
          itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options));
        } else {
          itemType = transformSchemaObject(schemaObject.items, options);
        }
      }

      const min: number =
        typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0 ? schemaObject.minItems : 0;
      const max: number | undefined =
        typeof schemaObject.maxItems === "number" && schemaObject.maxItems >= 0 && min <= schemaObject.maxItems
          ? schemaObject.maxItems
          : undefined;
      const estimateCodeSize = typeof max !== "number" ? min : (max * (max + 1) - min * (min - 1)) / 2;
      if (
        options.ctx.arrayLength &&
        (min !== 0 || max !== undefined) &&
        estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
      ) {
        if (min === max) {
          const elements: ts.TypeNode[] = [];
          for (let i = 0; i < min; i++) {
            elements.push(itemType);
          }
          return tsUnion([ts.factory.createTupleTypeNode(elements)]);
        } else if ((schemaObject.maxItems as number) > 0) {
          // if maxItems is set, then return a union of all permutations of possible tuple types
          const members: ts.TypeNode[] = [];
          // populate 1 short of min …
          for (let i = 0; i <= (max ?? 0) - min; i++) {
            const elements: ts.TypeNode[] = [];
            for (let j = min; j < i + min; j++) {
              elements.push(itemType);
            }
            members.push(ts.factory.createTupleTypeNode(elements));
          }
          return tsUnion(members);
        }
        // if maxItems not set, then return a simple tuple type the length of `min`
        else {
          const elements: ts.TypeNode[] = [];
          for (let i = 0; i < min; i++) {
            elements.push(itemType);
          }
          elements.push(ts.factory.createRestTypeNode(ts.factory.createArrayTypeNode(itemType)));
          return ts.factory.createTupleTypeNode(elements);
        }
      }

      const finalType =
        ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType)
          ? itemType
          : ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already

      return options.ctx.immutable
        ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType)
        : finalType;
    }

    // polymorphic, or 3.1 nullable
    if (Array.isArray(schemaObject.type) && !Array.isArray(schemaObject)) {
      // skip any primitive types that appear in oneOf as well
      const uniqueTypes: ts.TypeNode[] = [];
      if (Array.isArray(schemaObject.oneOf)) {
        for (const t of schemaObject.type) {
          if (
            (t === "boolean" || t === "string" || t === "number" || t === "integer" || t === "null") &&
            schemaObject.oneOf.find((o) => typeof o === "object" && "type" in o && o.type === t)
          ) {
            continue;
          }
          uniqueTypes.push(
            t === "null" || t === null
              ? NULL
              : transformSchemaObject(
                  { ...schemaObject, type: t, oneOf: undefined } as SchemaObject, // don’t stack oneOf transforms
                  options,
                ),
          );
        }
      } else {
        for (const t of schemaObject.type) {
          if (t === "null" || t === null) {
            uniqueTypes.push(NULL);
          } else {
            uniqueTypes.push(transformSchemaObject({ ...schemaObject, type: t } as SchemaObject, options));
          }
        }
      }
      return tsUnion(uniqueTypes);
    }
  }

  // type: object
  const coreObjectType: ts.TypeElement[] = [];

  // discriminators: explicit mapping on schema object
  for (const k of ["allOf", "anyOf"] as const) {
    if (!schemaObject[k]) {
      continue;
    }
    // for all magic inheritance, we will have already gathered it into
    // ctx.discriminators. But stop objects from referencing their own
    // discriminator meant for children (!schemaObject.discriminator)
    // and don't add discriminator properties if we already added/patched
    // them (options.ctx.discriminators.refsHandled.includes(options.path!).
    const discriminator =
      !schemaObject.discriminator &&
      !options.ctx.discriminators.refsHandled.includes(options.path ?? "") &&
      options.ctx.discriminators.objects[options.path ?? ""];
    if (discriminator) {
      coreObjectType.unshift(
        createDiscriminatorProperty(discriminator, {
          path: options.path ?? "",
          readonly: options.ctx.immutable,
        }),
      );
      break;
    }
  }

  if (
    ("properties" in schemaObject && schemaObject.properties && Object.keys(schemaObject.properties).length) ||
    ("additionalProperties" in schemaObject && schemaObject.additionalProperties) ||
    ("patternProperties" in schemaObject && schemaObject.patternProperties) ||
    ("$defs" in schemaObject && schemaObject.$defs)
  ) {
    // properties
    if (Object.keys(schemaObject.properties ?? {}).length) {
      for (const [k, v] of getEntries(schemaObject.properties ?? {}, options.ctx)) {
        if ((typeof v !== "object" && typeof v !== "boolean") || Array.isArray(v)) {
          throw new Error(
            `${options.path}: invalid property ${k}. Expected Schema Object or boolean, got ${
              Array.isArray(v) ? "Array" : typeof v
            }`,
          );
        }

        const { $ref, readOnly, hasDefault } =
          typeof v === "object"
            ? {
                $ref: "$ref" in v && v.$ref,
                readOnly: "readOnly" in v && v.readOnly,
                hasDefault: "default" in v && v.default !== undefined,
              }
            : {};

        // handle excludeDeprecated option
        if (options.ctx.excludeDeprecated) {
          const resolved = $ref ? options.ctx.resolve<SchemaObject>($ref) : v;
          if ((resolved as SchemaObject)?.deprecated) {
            continue;
          }
        }
        let optional =
          schemaObject.required?.includes(k) ||
          (schemaObject.required === undefined && options.ctx.propertiesRequiredByDefault) ||
          (hasDefault &&
            options.ctx.defaultNonNullable &&
            !options.path?.includes("parameters") &&
            !options.path?.includes("requestBody") &&
            !options.path?.includes("requestBodies")) // can’t be required, even with defaults
            ? undefined
            : QUESTION_TOKEN;
        let type = $ref
          ? oapiRef($ref)
          : transformSchemaObject(v, {
              ...options,
              path: createRef([options.path, k]),
            });

        if (typeof options.ctx.transform === "function") {
          const result = options.ctx.transform(v as SchemaObject, options);
          if (result && typeof result === "object") {
            if ("schema" in result) {
              type = result.schema;
              optional = result.questionToken ? QUESTION_TOKEN : optional;
            } else {
              type = result;
            }
          }
        }

        let property = ts.factory.createPropertySignature(
          /* modifiers     */ tsModifiers({
            readonly: options.ctx.immutable || readOnly,
          }),
          /* name          */ tsPropertyIndex(k),
          /* questionToken */ optional,
          /* type          */ type,
        );

        // Apply transformProperty hook if available
        if (typeof options.ctx.transformProperty === "function") {
          const result = options.ctx.transformProperty(property, v as SchemaObject, {
            ...options,
            path: createRef([options.path, k]),
          });
          if (result) {
            property = result;
          }
        }

        addJSDocComment(v, property);
        coreObjectType.push(property);
      }
    }

    // $defs
    if (schemaObject.$defs && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) {
      const defKeys: ts.TypeElement[] = [];
      for (const [k, v] of Object.entries(schemaObject.$defs)) {
        let property = ts.factory.createPropertySignature(
          /* modifiers    */ tsModifiers({
            readonly: options.ctx.immutable || ("readonly" in v && !!v.readOnly),
          }),
          /* name          */ tsPropertyIndex(k),
          /* questionToken */ undefined,
          /* type          */ transformSchemaObject(v, {
            ...options,
            path: createRef([options.path, "$defs", k]),
          }),
        );

        // Apply transformProperty hook if available
        if (typeof options.ctx.transformProperty === "function") {
          const result = options.ctx.transformProperty(property, v as SchemaObject, {
            ...options,
            path: createRef([options.path, "$defs", k]),
          });
          if (result) {
            property = result;
          }
        }

        addJSDocComment(v, property);
        defKeys.push(property);
      }
      coreObjectType.push(
        ts.factory.createPropertySignature(
          /* modifiers     */ undefined,
          /* name          */ tsPropertyIndex("$defs"),
          /* questionToken */ undefined,
          /* type          */ ts.factory.createTypeLiteralNode(defKeys),
        ),
      );
    }

    // additionalProperties / patternProperties
    const hasExplicitAdditionalProperties =
      typeof schemaObject.additionalProperties === "object" && Object.keys(schemaObject.additionalProperties).length;
    const hasImplicitAdditionalProperties =
      schemaObject.additionalProperties === true ||
      (typeof schemaObject.additionalProperties === "object" &&
        Object.keys(schemaObject.additionalProperties).length === 0);
    const hasExplicitPatternProperties =
      typeof schemaObject.patternProperties === "object" && Object.keys(schemaObject.patternProperties).length;
    const stringIndexTypes = [];
    if (hasExplicitAdditionalProperties) {
      stringIndexTypes.push(transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true));
    }
    if (hasImplicitAdditionalProperties || (!schemaObject.additionalProperties && options.ctx.additionalProperties)) {
      stringIndexTypes.push(UNKNOWN);
    }
    if (hasExplicitPatternProperties) {
      for (const [_, v] of getEntries(schemaObject.patternProperties ?? {}, options.ctx)) {
        stringIndexTypes.push(transformSchemaObject(v, options));
      }
    }

    if (stringIndexTypes.length === 0) {
      return coreObjectType.length ? ts.factory.createTypeLiteralNode(coreObjectType) : undefined;
    }

    const stringIndexType = tsUnion(stringIndexTypes);

    return tsIntersection([
      ...(coreObjectType.length ? [ts.factory.createTypeLiteralNode(coreObjectType)] : []),
      ts.factory.createTypeLiteralNode([
        ts.factory.createIndexSignature(
          /* modifiers  */ tsModifiers({
            readonly: options.ctx.immutable,
          }),
          /* parameters */ [
            ts.factory.createParameterDeclaration(
              /* modifiers      */ undefined,
              /* dotDotDotToken */ undefined,
              /* name           */ ts.factory.createIdentifier("key"),
              /* questionToken  */ undefined,
              /* type           */ STRING,
            ),
          ],
          /* type       */ stringIndexType,
        ),
      ]),
    ]);
  }

  return coreObjectType.length ? ts.factory.createTypeLiteralNode(coreObjectType) : undefined;
}

/**
 * Check if an object has a key
 * @param possibleObject - The object to check
 * @param key - The key to check for
 * @returns True if the object has the key, false otherwise
 */
function hasKey<K extends string>(possibleObject: unknown, key: K): possibleObject is { [key in K]: unknown } {
  return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject;
}
